Explore advanced TypeScript features like template literal types and conditional types to write more expressive and maintainable code. Master type manipulation for complex scenarios.
TypeScript Advanced Types: Mastering Template Literal and Conditional Types
TypeScript's strength lies in its powerful type system. While basic types like string, number, and boolean are sufficient for many scenarios, advanced features like template literal types and conditional types unlock a new level of expressiveness and type safety. This guide provides a comprehensive overview of these advanced types, exploring their capabilities and demonstrating practical applications.
Understanding Template Literal Types
Template literal types build upon JavaScript's template literals, allowing you to define types based on string interpolation. This enables the creation of types that represent specific string patterns, making your code more robust and predictable.
Basic Syntax and Usage
Template literal types use backticks (`) to enclose the type definition, similar to JavaScript template literals. Within the backticks, you can interpolate other types using the ${} syntax. This is where the magic happens – you're essentially creating a type that is a string, constructed at compile time based on the types inside the interpolation.
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type APIEndpoint = `/api/${string}`;
// Example Usage
const getEndpoint: APIEndpoint = "/api/users"; // Valid
const postEndpoint: APIEndpoint = "/api/products/123"; // Valid
const invalidEndpoint: APIEndpoint = "/admin/settings"; // TypeScript will not show an error here as `string` can be anything
In this example, APIEndpoint is a type that represents any string starting with /api/. While this basic example is useful, the true power of template literal types emerges when combined with more specific type constraints.
Combining with Union Types
Template literal types truly shine when used with union types. This allows you to create types that represent a specific set of string combinations.
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type APIPath = "users" | "products" | "orders";
type APIEndpoint = `/${APIPath}/${HTTPMethod}`;
// Valid API Endpoints
const getUsers: APIEndpoint = "/users/GET";
const postProducts: APIEndpoint = "/products/POST";
// Invalid API Endpoints (will result in TypeScript errors)
// const invalidEndpoint: APIEndpoint = "/users/PATCH"; // Error: "/users/PATCH" is not assignable to type "/users/GET" | "/users/POST" | "/users/PUT" | "/users/DELETE" | "/products/GET" | "/products/POST" | ... 3 more ... | "/orders/DELETE".
Now, APIEndpoint is a more restrictive type that only allows specific combinations of API paths and HTTP methods. TypeScript will flag any attempts to use invalid combinations, enhancing type safety.
String Manipulation with Template Literal Types
TypeScript provides intrinsic string manipulation types that work seamlessly with template literal types. These types allow you to transform strings at compile time.
- Uppercase: Converts a string to uppercase.
- Lowercase: Converts a string to lowercase.
- Capitalize: Capitalizes the first letter of a string.
- Uncapitalize: Uncapitalizes the first letter of a string.
type Greeting = "hello world";
type UppercaseGreeting = Uppercase; // "HELLO WORLD"
type LowercaseGreeting = Lowercase; // "hello world"
type CapitalizedGreeting = Capitalize; // "Hello world"
type UncapitalizedGreeting = Uncapitalize; // "hello world"
These string manipulation types are particularly useful for automatically generating types based on naming conventions. For example, you could derive action types from event names or vice versa.
Practical Applications of Template Literal Types
- API Endpoint Definition: As demonstrated above, defining API endpoints with precise type constraints.
- Event Handling: Creating types for event names with specific prefixes and suffixes.
- CSS Class Generation: Generating CSS class names based on component names and states.
- Database Query Building: Ensuring type safety when constructing database queries.
International Example: Currency Formatting
Imagine building a financial application that supports multiple currencies. You can use template literal types to enforce correct currency formatting.
type CurrencyCode = "USD" | "EUR" | "GBP" | "JPY";
type CurrencyFormat = `${number} ${T}`;
const priceUSD: CurrencyFormat<"USD"> = "100 USD"; // Valid
const priceEUR: CurrencyFormat<"EUR"> = "50 EUR"; // Valid
// const priceInvalid: CurrencyFormat<"USD"> = "100 EUR"; // Error: Type 'string' is not assignable to type '`${number} USD`'.
function formatCurrency(amount: number, currency: T): CurrencyFormat {
return `${amount} ${currency}`;
}
const formattedUSD = formatCurrency(250, "USD"); // Type: "250 USD"
const formattedEUR = formatCurrency(100, "EUR"); // Type: "100 EUR"
This example ensures that currency values are always formatted with the correct currency code, preventing potential errors.
Delving into Conditional Types
Conditional types introduce branching logic into TypeScript's type system, allowing you to define types that depend on other types. This feature is incredibly powerful for creating highly flexible and reusable type definitions.
Basic Syntax and Usage
Conditional types use the infer keyword and the ternary operator (condition ? trueType : falseType) to define type conditions.
type IsString = T extends string ? true : false;
type StringCheck = IsString; // type StringCheck = true
type NumberCheck = IsString; // type NumberCheck = false
In this example, IsString is a conditional type that checks if T is assignable to string. If it is, the type resolves to true; otherwise, it resolves to false.
The infer Keyword
The infer keyword allows you to extract a type from a type. This is particularly useful when working with complex types like function types or array types.
type ReturnType any> = T extends (...args: any) => infer R ? R : any;
function add(a: number, b: number): number {
return a + b;
}
type AddReturnType = ReturnType; // type AddReturnType = number
In this example, ReturnType extracts the return type of a function type T. The infer R part of the conditional type infers the return type and assigns it to the type variable R. If T is not a function type, the type resolves to any.
Distributive Conditional Types
Conditional types become distributive when the checked type is a naked type parameter. This means that the conditional type is applied to each member of the union type separately.
type ToArray = T extends any ? T[] : never;
type NumberOrStringArray = ToArray; // type NumberOrStringArray = string[] | number[]
In this example, ToArray converts a type T to an array type. Because T is a naked type parameter (not wrapped in another type), the conditional type is applied to number and string separately, resulting in a union of number[] and string[].
Practical Applications of Conditional Types
- Extracting Return Types: As demonstrated above, extracting the return type of a function.
- Filtering Types from a Union: Creating a type that contains only specific types from a union.
- Defining Overloaded Function Types: Creating different function types based on input types.
- Creating Type Guards: Defining functions that narrow down the type of a variable.
International Example: Handling Different Date Formats
Different regions of the world use different date formats. You can use conditional types to handle these variations.
type DateFormat = "YYYY-MM-DD" | "MM/DD/YYYY" | "DD.MM.YYYY";
type ParseDate = T extends "YYYY-MM-DD"
? { year: number; month: number; day: number; format: "YYYY-MM-DD" }
: T extends "MM/DD/YYYY"
? { month: number; day: number; year: number; format: "MM/DD/YYYY" }
: T extends "DD.MM.YYYY"
? { day: number; month: number; year: number; format: "DD.MM.YYYY" }
: never;
function parseDate(dateString: string, format: T): ParseDate {
// (Implementation would handle different date formats)
if (format === "YYYY-MM-DD") {
const [year, month, day] = dateString.split("-").map(Number);
return { year, month, day, format } as ParseDate;
} else if (format === "MM/DD/YYYY") {
const [month, day, year] = dateString.split("/").map(Number);
return { month, day, year, format } as ParseDate;
} else if (format === "DD.MM.YYYY") {
const [day, month, year] = dateString.split(".").map(Number);
return { day, month, year, format } as ParseDate;
} else {
throw new Error("Invalid date format");
}
}
const parsedDateISO = parseDate("2023-10-27", "YYYY-MM-DD"); // Type: { year: number; month: number; day: number; format: "YYYY-MM-DD"; }
const parsedDateUS = parseDate("10/27/2023", "MM/DD/YYYY"); // Type: { month: number; day: number; year: number; format: "MM/DD/YYYY"; }
const parsedDateEU = parseDate("27.10.2023", "DD.MM.YYYY"); // Type: { day: number; month: number; year: number; format: "DD.MM.YYYY"; }
console.log(parsedDateISO.year); // Access the year knowing it will be there
This example uses conditional types to define different date parsing functions based on the specified date format. The ParseDate type ensures that the returned object has the correct properties based on the format.
Combining Template Literal and Conditional Types
The real power comes when you combine template literal types and conditional types. This allows for incredibly powerful type manipulations.
type EventName = `on${Capitalize}`;
type ExtractEventPayload = T extends EventName
? { type: T; payload: any } // Simplified for demonstration
: never;
type ClickEvent = EventName<"click">; // "onClick"
type MouseOverEvent = EventName<"mouseOver">; // "onMouseOver"
//Example function that takes a type
function processEvent(event: T): ExtractEventPayload {
//In a real implementation, we would actually dispatch the event.
console.log(`Processing event ${event}`);
//In a real implementation, the payload would be based on event type.
return { type: event, payload: {} } as ExtractEventPayload;
}
//Note that the return types are very specific:
const clickEvent = processEvent("onClick"); // { type: "onClick"; payload: any; }
const mouseOverEvent = processEvent("onMouseOver"); // { type: "onMouseOver"; payload: any; }
//If you use other strings, you get never:
// const someOtherEvent = processEvent("someOtherEvent"); // Type is `never`
Best Practices and Considerations
- Keep it Simple: While powerful, these advanced types can become complex quickly. Strive for clarity and maintainability.
- Test Thoroughly: Ensure that your type definitions behave as expected by writing comprehensive unit tests.
- Document Your Code: Clearly document the purpose and behavior of your advanced types to improve code readability.
- Consider Performance: Excessive use of advanced types can impact compilation time. Profile your code and optimize where necessary.
Conclusion
Template literal types and conditional types are powerful tools in TypeScript's arsenal. By mastering these advanced types, you can write more expressive, maintainable, and type-safe code. These features enable you to capture complex relationships between types, enforce stricter constraints, and create highly reusable type definitions. Embrace these techniques to elevate your TypeScript skills and build robust and scalable applications for a global audience.